// Copyright 2011-2015 Canonical Ltd.
// Licensed under the LGPLv3, see LICENCE file for details.

package charm

import (
	"fmt"
	"io"
	"regexp"
	"strings"

	"github.com/juju/errors"
	gjs "github.com/juju/gojsonschema"
	"gopkg.in/yaml.v2"
)

var prohibitedSchemaKeys = map[string]bool{"$ref": true, "$schema": true}

var actionNameRule = regexp.MustCompile("^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")

// Export `actionNameRule` variable to different contexts.
func GetActionNameRule() *regexp.Regexp {
	return actionNameRule
}

// Actions defines the available actions for the charm. Additional params
// may be added as metadata at a future time (e.g. version.)
type Actions struct {
	ActionSpecs map[string]ActionSpec `yaml:"actions,omitempty"`
}

// Build this out further if it becomes necessary.
func NewActions() *Actions {
	return &Actions{}
}

// ActionSpec is a definition of the parameters and traits of an Action.
// The Params map is expected to conform to JSON-Schema Draft 4 as defined at
// http://json-schema.org/draft-04/schema# (see http://json-schema.org/latest/json-schema-core.html)
type ActionSpec struct {
	Description    string
	Parallel       bool
	ExecutionGroup string
	Params         map[string]interface{}
}

// ValidateParams validates the passed params map against the given ActionSpec
// and returns any error encountered.
// Usage:
//
//	err := ch.Actions().ActionSpecs["snapshot"].ValidateParams(someMap)
func (spec *ActionSpec) ValidateParams(params map[string]interface{}) error {
	// Load the schema from the Charm.
	specLoader := gjs.NewGoLoader(spec.Params)
	schema, err := gjs.NewSchema(specLoader)
	if err != nil {
		return err
	}

	// Load the params as a document to validate.
	// If an empty map was passed, we need an empty map to validate against.
	p := map[string]interface{}{}
	if len(params) > 0 {
		p = params
	}
	docLoader := gjs.NewGoLoader(p)
	results, err := schema.Validate(docLoader)
	if err != nil {
		return err
	}
	if results.Valid() {
		return nil
	}

	// Handle any errors generated by the Validate().
	var errorStrings []string
	for _, validationError := range results.Errors() {
		errorStrings = append(errorStrings, validationError.String())
	}
	return errors.Errorf("validation failed: %s", strings.Join(errorStrings, "; "))
}

// InsertDefaults inserts the schema's default values in target using
// github.com/juju/gojsonschema.  If a nil target is received, an empty map
// will be created as the target.  The target is then mutated to include the
// defaults.
//
// The returned map will be the transformed or created target map.
func (spec *ActionSpec) InsertDefaults(target map[string]interface{}) (map[string]interface{}, error) {
	specLoader := gjs.NewGoLoader(spec.Params)
	schema, err := gjs.NewSchema(specLoader)
	if err != nil {
		return target, err
	}

	return schema.InsertDefaults(target)
}

// ReadActionsYaml builds an Actions spec from a charm's actions.yaml.
func ReadActionsYaml(charmName string, r io.Reader) (*Actions, error) {
	data, err := io.ReadAll(r)
	if err != nil {
		return nil, err
	}

	result := &Actions{
		ActionSpecs: map[string]ActionSpec{},
	}

	var unmarshaledActions map[string]map[string]interface{}
	if err := yaml.Unmarshal(data, &unmarshaledActions); err != nil {
		return nil, err
	}

	for name, actionSpec := range unmarshaledActions {
		if valid := actionNameRule.MatchString(name); !valid {
			return nil, fmt.Errorf("bad action name %s", name)
		}
		if reserved, reason := reservedName(charmName, name); reserved {
			return nil, fmt.Errorf(
				"cannot use action name %s: %s",
				name, reason,
			)
		}

		desc := "No description"
		parallel := false
		executionGroup := ""
		thisActionSchema := map[string]interface{}{
			"description":          desc,
			"type":                 "object",
			"title":                name,
			"properties":           map[string]interface{}{},
			"additionalProperties": false,
		}

		for key, value := range actionSpec {
			switch key {
			case "description":
				// These fields must be strings.
				typed, ok := value.(string)
				if !ok {
					return nil, errors.Errorf("value for schema key %q must be a string", key)
				}
				thisActionSchema[key] = typed
				desc = typed
			case "title":
				// These fields must be strings.
				typed, ok := value.(string)
				if !ok {
					return nil, errors.Errorf("value for schema key %q must be a string", key)
				}
				thisActionSchema[key] = typed
			case "required":
				typed, ok := value.([]interface{})
				if !ok {
					return nil, errors.Errorf("value for schema key %q must be a YAML list", key)
				}
				thisActionSchema[key] = typed
			case "parallel":
				typed, ok := value.(bool)
				if !ok {
					return nil, errors.Errorf("value for schema key %q must be a bool", key)
				}
				parallel = typed
			case "execution-group":
				typed, ok := value.(string)
				if !ok {
					return nil, errors.Errorf("value for schema key %q must be a string", key)
				}
				executionGroup = typed
			case "params":
				// Clean any map[interface{}]interface{}s out so they don't
				// cause problems with BSON serialization later.
				cleansedParams, err := cleanse(value)
				if err != nil {
					return nil, err
				}

				// JSON-Schema must be a map
				typed, ok := cleansedParams.(map[string]interface{})
				if !ok {
					return nil, errors.New("params failed to parse as a map")
				}
				thisActionSchema["properties"] = typed
			default:
				// In case this has nested maps, we must clean them out.
				typed, err := cleanse(value)
				if err != nil {
					return nil, err
				}
				thisActionSchema[key] = typed
			}
		}

		// Make sure the new Params doc conforms to JSON-Schema
		// Draft 4 (http://json-schema.org/latest/json-schema-core.html)
		schemaLoader := gjs.NewGoLoader(thisActionSchema)
		_, err := gjs.NewSchema(schemaLoader)
		if err != nil {
			return nil, errors.Annotatef(err, "invalid params schema for action schema %s", name)
		}

		// Now assign the resulting schema to the final entry for the result.
		result.ActionSpecs[name] = ActionSpec{
			Description:    desc,
			Parallel:       parallel,
			ExecutionGroup: executionGroup,
			Params:         thisActionSchema,
		}
	}
	return result, nil
}

// cleanse rejects schemas containing references or maps keyed with non-
// strings, and coerces acceptable maps to contain only maps with string keys.
func cleanse(input interface{}) (interface{}, error) {
	switch typedInput := input.(type) {
	// In this case, recurse in.
	case map[string]interface{}:
		newMap := make(map[string]interface{})
		for key, value := range typedInput {

			if prohibitedSchemaKeys[key] {
				return nil, fmt.Errorf("schema key %q not compatible with this version of juju", key)
			}

			newValue, err := cleanse(value)
			if err != nil {
				return nil, err
			}
			newMap[key] = newValue
		}
		return newMap, nil

	// Coerce keys to strings and error out if there's a problem; then recurse.
	case map[interface{}]interface{}:
		newMap := make(map[string]interface{})
		for key, value := range typedInput {
			typedKey, ok := key.(string)
			if !ok {
				return nil, errors.New("map keyed with non-string value")
			}
			newMap[typedKey] = value
		}
		return cleanse(newMap)

	// Recurse
	case []interface{}:
		newSlice := make([]interface{}, 0)
		for _, sliceValue := range typedInput {
			newSliceValue, err := cleanse(sliceValue)
			if err != nil {
				return nil, errors.New("map keyed with non-string value")
			}
			newSlice = append(newSlice, newSliceValue)
		}
		return newSlice, nil

	// Other kinds of values are OK.
	default:
		return input, nil
	}
}

// recurseMapOnKeys returns the value of a map keyed recursively by the
// strings given in "keys".  Thus, recurseMapOnKeys({a,b}, {a:{b:{c:d}}})
// would return {c:d}.
func recurseMapOnKeys(keys []string, params map[string]interface{}) (interface{}, bool) {
	key, rest := keys[0], keys[1:]
	answer, ok := params[key]

	// If we're out of keys, we have our answer.
	if len(rest) == 0 {
		return answer, ok
	}

	// If we're not out of keys, but we tried a key that wasn't in the
	// map, there's no answer.
	if !ok {
		return nil, false
	}

	switch typed := answer.(type) {
	// If our value is a map[s]i{}, we can keep recursing.
	case map[string]interface{}:
		return recurseMapOnKeys(keys[1:], typed)
	// If it's a map[i{}]i{}, we need to check whether it's a map[s]i{}.
	case map[interface{}]interface{}:
		m := make(map[string]interface{})
		for k, v := range typed {
			if tK, ok := k.(string); ok {
				m[tK] = v
			} else {
				// If it's not, we don't have something we
				// can work with.
				return nil, false
			}
		}
		// If it is, recurse into it.
		return recurseMapOnKeys(keys[1:], m)

	// Otherwise, we're trying to recurse into something we don't know
	// how to deal with, so our answer is that we don't have an answer.
	default:
		return nil, false
	}
}
